β

Как стать автором
Курсы по фронтенду
Да случится мэтч: оцени совместимость с IT в ритейле

Моя лентаВсе потоки

divan0 26 ноя 2015 в 17:36

Танцы с мьютексами в Go

9 мин

76K

Go*

Туториал

Перевод

Автор оригинала: Ralph Caraveo III

Перевод обучающей статьи разработчика из SendGrid о том, когда и зачем можно и нужно использовать «традиционные» методы синхронизации данных в Go.

Уровень чтения: средний (intermediate) — эта статья подразумевает, что вы знакомы с основами Go и моделью concurrency, и, как минимум, знакомы с подходами к синхронизации данных методами блокировок и каналов.

Заметка читателю: На этот пост меня вдохновил хороший друг. Когда я помог ему разобраться с некоторыми гонками в его коде и постарался научить его искусству синхронизации данных так хорошо, насколько только был способен, я понял, что эти советы могут быть полезны и другим. Так что, будь это унаследованная кодовая база, в которой определенные решения по дизайну уже были приняты до вас, или вы просто хотите лучше понимать традиционные примитивы синхронизации в Go — эта статья может быть для вас.

Когда я впервые начал работать с языком программирования Go, я моментально влюбился в слоган «Не общайтесь разделением памяти. Разделяйте память через общение.» (Don’t communicate by sharing memory; share memory by communicating.) Для меня это означало писать весь конкурентный (concurrent) код «правильным» путем, используя каналы всегда-всегда. Я считал, что используя потенциал каналов, я гарантированно избегаю проблем с конкурентностью, блокировками, дедлоками и т.д.

По мере того, как я прогрессировал в Go, учился писать код на Go более идиоматически и изучал лучшие практики, я регулярно натыкался на большие кодовые базы, где люди регулярно использовали примитивы sync/mutex, а также sync/atomic, и несколько других «низкоуровневых» примитивов синхронизации «старой школы». Мои первые мысли были — ну, они явно делают это неверно, и, очевидно, они не смотрели ни одного выступления Роба Пайка о плюсах реализации конкуретного кода с помощью каналов, в которых он часто рассказывает о дизайне, основанном на труде Тони Хоара Communicating Sequential Processes.

Но реальность была сурова. Go-сообщество цитировало этот слоган там и тут, но заглядывая во многие open source проекты, я видел, что мьютексы повсюду и их много. Я боролся с этой загадкой некоторое время, но, в итоге, я увидел свет в конце тоннеля, и настало время засучить рукава и отложить каналы в сторонку. Теперь, быстро перемотаем на 2015 год, в котором я пишу на Go уже около 2.5 лет, в течение которых у меня было прозрение или даже два касательно более традиционных примитивов синхронизации вроде блокировок мьютексами. Давайте, спросите меня сейчас, в 2015? Ей, @deckarep, ты всё еще пишешь конкурентные программы используя только лишь каналы? Я отвечу — нет, и вот почему.

Во-первых, давайте не забывать о важности прагматичности. Когда речь заходит о защите состояния объекта методом блокировок или каналов, давайте зададимся вопросом — «Какой же метод я должен использовать?». И, как оказалось, есть очень хороший пост, который замечательно отвечает на этот вопрос:

Используйте тот метод, который наиболее выразителен и/или прост в вашем случае.

Частая ошибка новичков в Go это переиспользовать каналы и горутины просто потому что это возможно, и/или потому что это весело. Не бойтесь использовать sync.Mutex, если он решает вашу проблему лучше всего. Go прагматичен в том, чтобы давать вам те инструменты решения задачи, которые подходят лучше, и не навязывает вам лишь один подход.

Обратите внимание на ключевые слова в этой цитате: выразителен, прост, переиспользовать, не бойтесь, прагматичен. Я могу честно признать некоторые озвученные тут вещи: я боялся, когда я впервые пробовал Go. Я был совсем новичок в языке, и мне необходимо было время, чтобы быстро делать выводы. Вы, наверняка, вынесете собственные выводы из упомянутой выше статьи, и из этой статьи, по мере того, как мы углубимся в принятые практики использования мьютексов и различных нюансов. Статья выше также неплохо описывает соображения касательно выбора между мьютексами и каналами.

Когда использовать Каналы: передача владения данными, распределение вычислений и передача асинхронных результатов.

Когда использовать Мьютексы: кэши, состояния.

В конце концов, каждое приложение разное, и может потребоваться немного экспериментов и ложных стартов. Указания выше лично мне помогают, но позвольте мне объяснить их чуть более подробно. Если вам нужно защитить доступ к простой структуре данных, такой как слайс, или map, или что-нибудь своё, и если интерфейс доступа к этой структуре данных прост и прямолинеен — начинайте с мьютекса. Это также помогает спрятать «грязные» подробности кода блокировки в вашем API. Конечные пользователи вашей структуры не должны заботиться о том, как она делает внутреннюю синхронизацию.

Если же ваша синхронизация на мьютексах начинает становиться громоздкой и вы начинаете плясать танцы с мьютексами, это самое время переключиться на другой подход. Ещё раз, примите, как данное, что мьютексы удобны для простых сценариев, чтобы защитить минимально разделяемые данные. Используйте их для того, для чего они и нужны, но уважайте их и не давайте им выйти из под контроля. Оглянитесь назад, посмотрите внимательно на логику вашей программы, и если вы сражаетесь с мьютексами, значит это повод переосмыслить ваш дизайн. Возможно переход на каналы гораздо лучше впишется в логику вашего приложения, или, ещё лучше, возможно вам и не нужно разделять состояние вообще.

Многопоточность не сложна — сложны блокировки.

Поймите, я не утверждаю, что мьютексы лучше каналов. Я всего лишь говорю, что вы должны быть знакомы с обеими методами синхронизации, и если видите, что ваше решение на каналах выглядит переусложнённым, знать, что у вас есть другие варианты. Примеры в этой статье служат цели помочь вам писать лучший, более поддерживаемый и надёжный код. Мы, как инженеры, должны быть сознательны в том, как мы подходим к работе с разделяемыми данными и состояниями гонок в мультипоточных приложениях. Go позволяет невероятно легко писать высокопроизводительный конкурентные и/или параллельные приложения, но подвохи есть, и мы должны уметь аккуратно их обходить, создавая правильный код. Давайте посмотрим на них подробнее:

Номер 1: Определяя структуру, в которой мьютекс должен защищать одно или больше значений, помещайте мьютекс выше тех полей, доступ к которым, он будет защищать. Вот пример этой идиомы в исходном коде Go. Имейте ввиду, что это всего лишь договорённость, и никак не влияет на логику кода.

var sum struct {
sync.Mutex // <-- этот мьютекс защищает
i int // <-- это поле под ним
}

Номер 2: держите блокировку не дольше, чем она на самом деле требуется. Пример — если возможно, не держите мьютекс во время IO-вызова. Наоборот, постарайтесь защищать ваши данные только минимально необходимое время. Если вы сделаете как-нибудь вот так в веб-обработчике, вы просто потеряете преимущества конкурентности, сериализовав доступ к обработчику:

// В коде ниже подразумевается, что `mu` существует только
// для защиты переменной cache
// NOTE: Простите за игнор ошибок, это для краткости примера

// Не делайте так, если это возможно
func doSomething(){
mu.Lock()
item := cache["myKey"]
http.Get() // какой-нибудь дорогой IO-вызов
mu.Unlock()
}
// Вместо этого, делайте как-нибудь так
func doSomething(){
mu.Lock()
item := cache["myKey"]
mu.Unlock()
http.Get() // Это может занять время, но нам ок
}

Номер 3: Используйте defer, чтобы разблокировать мьютекс там где у функции есть несколько точек выхода. Для вас это означает меньше ручного кода и может помочь избежать дедлоков, когда кто-то меняет код через 3 месяца и добавляет новую точку выхода, упустив из виду блокировку.

func doSomething() {
mu.Lock()
defer mu.Unlock()
err := ...
if err != nil {
//log error
return // <-- разблокировка произойдет здесь
}

err = ...
if err != nil {
//log error
return // <-- или тут
}
return // <-- и, конечно, тут тоже
}

При этом, постарайтесь не зависеть вслепую от defer во всех случаях подряд. К примеру, следующий код — это ловушка, в которую вы можете попасться, если вы думаете, что defer-ы выполняются не при выходе из функции, а при выходе из области видимости (scope):

func doSomething(){
for {
mu.Lock()
defer mu.Unlock()

// какой-нибудь интересный код
// <-- defer не будет выполнен тут, как кто-нибудь *может* подумать
}
// <-- он(и) будут исполнены тут, при выходе из функции
}
// И поэтому в коде выше будет дедлок!

Наконец, не забывайте, что defer можно вообще не использовать в простых случаях без множественных точек выхода. Отложенные выполнения (defer) имеют небольшие накладные расходы, хотя зачастую ими можно пренебречь. И рассматривайте это, как очень преждевременную и, зачастую, лишнюю оптимизацию.

Номер 4: точная (fine-grained) блокировка может давать лучшую производительность ценой более сложного кода для управления ею, в то время, как более грубая блокировка может быть менее производительна, но делать код проще. Но опять же, будьте прагматичны в оценках дизайна. Если вы видите, что «танцуете с мьютексами», то, скорее всего, это подходящий момент для рефакторинга и перехода на синхронизацию посредством каналов.

Номер 5: Как упоминалось выше, хорошей практикой является инкапсулировать используемый метод синхронизации. Пользователи вашего пакета не должны заботится, каким именно образом вы защищаете данные в вашем коде.

В примере ниже, представьте, что мы представляем метод get(), который будет выбирать код из кэша только если в нём есть хотя бы одно значение. И поскольку мы должны блокировать как обращение к содержимому, так и подсчет значений, этот код приведет к дедлоку:

package main

import (
"fmt"
"sync"
)

type DataStore struct {
sync.Mutex // ← этот мьютекс охраняет кэш ниже
cache map[string]string
}

func New() *DataStore {
return &DataStore{
cache: make(map[string]string),
}
}

func (ds *DataStore) set(key string, value string) {
ds.Lock()
defer ds.Unlock()
ds.cache[key] = value
}

func (ds *DataStore) get(key string) string {
ds.Lock()
defer ds.Unlock()
if ds.count() > 0 { // <-- count() тоже блокируется!
item := ds.cache[key]
return item
}
return ""
}

func (ds *DataStore) count() int {
ds.Lock()
defer ds.Unlock()
return len(ds.cache)
}

func main() {
/* Выполнение кода ниже приведет к дедлоку, так как метод get() заблокируется и метод count() также заблокируется перед тем как get() разблокирует мьютекс
*/
store := New()
store.set("Go", "Lang")
result := store.get("Go")
fmt.Println(result)
}

Поскольку мьютексы в Go нерекурсивны, предложенное решение может выглядеть так:

package main

import (
"fmt"
"sync"
)

type DataStore struct {
sync.Mutex // ← этот мьютекс защищает кэш ниже
cache map[string]string
}

func New() *DataStore {
return &DataStore{
cache: make(map[string]string),
}
}

func (ds *DataStore) set(key string, value string) {
ds.cache[key] = value
}

func (ds *DataStore) get(key string) string {
if ds.count() > 0 {
item := ds.cache[key]
return item
}
return ""
}

func (ds *DataStore) count() int {
return len(ds.cache)
}

func (ds *DataStore) Set(key string, value string) {
ds.Lock()
defer ds.Unlock()
ds.set(key, value)
}

func (ds *DataStore) Get(key string) string {
ds.Lock()
defer ds.Unlock()
return ds.get(key)
}

func (ds *DataStore) Count() int {
ds.Lock()
defer ds.Unlock()
return ds.count()
}

func main() {
store := New()
store.Set("Go", "Lang")
result := store.Get("Go")
fmt.Println(result)
}

Обратите внимание в этом коде, что для каждого не-экспортированного метода есть аналогичный экспортированный. Эти методы работают как публичный API, и заботятся о блокировках на этом уровне. Далее они вызывают неэкспортированные методы, которые вообще не заботятся о блокировках. Это гарантирует, что все вызовы ваших методов извне будут блокироваться лишь раз и лишены проблемы рекурсивной блокировки.

Номер 6: В примерах выше мы использовали простой sync.Mutex, который может только блокировать и разблокировать. sync.Mutex предоставляет одинаковые гарантии, вне зависимости от того читает ли горутина данные или пишет. Но существует также sync.RWMutex, который даёт более точную семантику блокировок для кода, который только обращается к данным. Когда же использовать RWMutex вместо стандартного Mutex?

Ответ: используйте RWMutex, когда вы абсолютно уверены, что код в вашей критической секции не изменяет охраняемые данные.

// я могу смело использовать RLock() для счетчика, так как он не меняет данные
func count() {
rw.RLock() // <-- заметьте букву R в RLock (read-lock)
defer rw.RUnlock() // <-- заметьте букву R в RUnlock()
return len(sharedState)
}

// Но я должен использовать Lock() для set(), который меняет данные
func set(key string, value string) {
rw.Lock() // <-- заметьте, тут мы берем "обычный" Lock (write-lock)
defer rw.Unlock() // <-- а тут Unlock(), без R
sharedState[key] = value // <-- изменяет состояние(данные)
}

В коде выше мы подразумеваем, что переменная `sharedState` — это некий объект, возможно map, в котором мы можем считать его длинну. Поскольку функция count() гарантирует, что наш объект не изменяется, то мы можем смело вызывать её параллельно из любого количества ридеров (горутин). В некоторых сценариях это может уменьшить количество горутин в состоянии блокировки и потенциально дать прирост производительности в сценарии, где происходит много read-only обращений к данным. Но помните, если у вас есть код, меняющий данные, как в set(), вы обязаны использовать rw.Lock() вместо rw.RLock().

Номер 7: познакомьтесь с адски крутым и встроенным race-детектором в Go. Этот детектор заработал себе репутацию, найдя состояния гонки даже в стандартной библиотеке Go в своё время. Именно поэтому он встроен в инструментарий Go и есть немало выступлений и статей о нём, которые расскажут про него лучше, чем я.

если вы ещё не запускаете свои unit/integration тесты с включенным рейс-детектором в вашем CI — настройте это прямо сейчас

если ваши тесты не тестируют параллельный доступ к вашему API/коду — детектор вам сильно не поможет

не запускайте программу с race-детектором в продакшене, там есть накладные расходы, которые уменьшают производительность

если race-детектор нашел состояние гонки — это реальная гонка

состояния гонки могут быть и при синхронизации через каналы, если вы неосторожны

никакие блокировки в мире вас не спасут, если горутины читают или пишут разделяемые данные вне пределов критической секции

если авторы Go могут иногда писать код, в котором есть гонки, то вы тоже можете

Я надеюсь, эта статья даёт достаточно ёмкое представление о том, как и когда использовать мьютексы в Go. Пожалуйста, экспериментируйте с низкоуровневыми примитивами синхронизации в Go, делайте ошибки, научитесь на них, цените и понимайте инструментарий. И прежде всего, будьте прагматичны в вашем коде, используйте правильные инструменты для каждого конкретного случая. Не бойтесь, как боялся я вначале. Если бы я всегда слушал все негативные вещи, которые говорят про блокировки, я бы сейчас не был в этом бизнесе, создавая крутейшие распределённые системы используя такие крутые технологии, как Go.

Примечание: я люблю обратную связь, так что если вы находите этот материал полезным, пинганите меня, твитните или дайте мне конструктивный отзыв.

Спасибо и хорошего кодинга!

Теги:

go

mutex

channels

locking

Хабы:

Go

+24

234

22
+22

192

Карма

0

Рейтинг

divan0 @divan0

Пользователь

Подписаться

Комментарии 22
+22

Публикации

Лучшие за суткиПохожие

A_Tsymbalyuk 13 часов назад

Физика ошибок не прощает. Она догонит тебя на крыше и заставит платить (дважды)

Простой

6 мин

8.2K

Кейс

+40

35

25
+25

BabayMazay 12 часов назад

Самодельные французские радиолампы 1920 г. Часть 3. Разрядная трубка, лампа накаливания

Средний

9 мин

1.5K

Ретроспектива

+33

9

11
+11

Bright_Translate 8 часов назад

Как я от нечего делать замедлил Postgres в 42 000 раз

Средний

11 мин

2.7K

Кейс

Перевод

+27

21

2
+2

butivskiydm 11 часов назад

Моё мини-исследование про продолжительность жизни в России

Простой

2 мин

6.4K

Мнение

+22

17

63
+63

lokosst 13 часов назад

Выделенные серверы + Kubernetes: практическое руководство

17 мин

2.4K

Туториал

+21

24

0

JetHabr 11 часов назад

«Плохие парни работают просто»: пентестеры разбирают реальные кейсы

Средний

9 мин

2.2K

Кейс

+17

19

1
+1

k0mar0v 14 часов назад

HTC Desire HD: история, дизайн и возможности легендарного смартфона

6 мин

2.6K

+17

5

5
+5

MaFrance351 13 часов назад

Домофон Лидер-М. Как скрестить трубку с ключом и всем проиграть

Простой

4 мин

2.6K

+16

5

6
+6

psygalaxy 14 часов назад

Как мы ускорили проверку документации с помощью AI-агента: от боли к решению

Средний

25 мин

1.8K

Обзор

+16

30

9
+9

kubelet 17 часов назад

DNS-хаос, зомби-поды и майнеры в кластере: самые невероятные случаи при работе с Kubernetes

Простой

14 мин

1.2K

Кейс

+16

23

1
+1

35 спикеров, аналитическая дженга и море практики: что будет на конференции для системных и бизнес-аналитиков от Контура
Турбо

Показать еще

Минуточку внимания

Турбо

Полный свайп: оцени, подходит ли тебе работа в IT-ритейле

Опрос

Да начнётся битва: выбираем лучший IT-бренд работодателя

Событие

Как построить IT-бренд через вирусный контент и юмор

Вопросы и ответы

Trace/breakpoint trap (core dumped)?
LinuxПростой2 ответа

Неточность формулировки в книге или же я не понимаю?
GoПростой2 ответа

Зачем for {select} оборачивать в еще один select с time.Ticker?
GoПростой3 ответа

Какие гарантии на выравнивание адреса указателя?
GoСредний1 ответ

Как поменять env переменную PATH в golang (MacOS)?
GoСредний2 ответа

Больше вопросов на Хабр Q&A

Читают сейчас

Как превратить ноутбук в полноценное рабочее место не хуже стационарного ПК

12K

71
+71

Нашел ЛЯП в игре 5букв от Т-банка, который позволяет со 100% вероятностью угадать скрытое слово

54K

115
+115

Как выйти из IT?.. и пойти слесарем на завод. Моя попыточка дауншифтинга [4\5]

1.6K

12
+12

Моё мини-исследование про продолжительность жизни в России

6.4K

63
+63

GPT-5 получит модуль для борьбы с «галлюцинациями» — The Information

849

3
+3

35 спикеров, аналитическая дженга и море практики: что будет на конференции для системных и бизнес-аналитиков от Контура
Турбо

Истории

История Яндекс Почты

С Днём сисадмина!

Как расти в ИТ: советы, гайды и опыт сеньоров

1 055 450 очков в облаке – попробуй обогнать

Годнота из блогов компаний

Ближайшие события

7 августа

Мем-пати: как построить IT-бренд через вирусный контент и юмор

Москва

Больше событий в календаре

Маркетинг

13 августа

Как повысить эффективность врача в стоматологии на 25% с помощью ИИ?

Онлайн

Больше событий в календаре

Менеджмент

13 августа

Тест-драйв Хабра для маркетологов и пиарщиков

Москва

Больше событий в календаре

Маркетинг

16 августа

«ИТ-летник» — конференция от Совкомбанк Технологий

Казань

Больше событий в календаре

Разработка

Аналитика

3 сентября

«GoCloud Tech 2025» — IT-конференция про облака и AI

Москва • Онлайн

Больше событий в календаре

Разработка

18 – 19 сентября

Конференция по корпоративному онлайн-обучению iSpring Days 2025

Москва • Онлайн

Больше событий в календаре

Администрирование

Менеджмент

Другое

19 сентября

Премия для технологических стартапов Startech.Awards 2025

Москва • Онлайн

Больше событий в календаре

Другое

30 сентября

VII премия в сфере фармацевтического маркетинга и креатива MedMen Awards 2025

Онлайн

Больше событий в календаре

Маркетинг

Другое

30 октября

Премия «Проксима»

Москва

Больше событий в календаре

Маркетинг

Реклама

АО СЕЛЕКТЕЛ

ИНН 7810962785

erid: 2VSb5zDBFAH

1 000 000 стартапам

Кешбэк 30% на год в Selectel!

Ваш аккаунт

Профиль

Трекер

Диалоги

Настройки

ППА

Разделы

Статьи

Новости

Хабы

Компании

Авторы

Песочница

Информация

Устройство сайта

Для авторов

Для компаний

Документы

Соглашение

Конфиденциальность

Услуги

Корпоративный блог

Медийная реклама

Нативные проекты

Образовательные программы

Стартапам

Настройка языкаТехническая поддержка
© 2006–2025, Habr